| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- <template>
- <div class="admin--page-content">
- <div v-if="isLoading" class="admin--table-loading" style="padding:60px;text-align:center;">
- 데이터를 불러오는 중...
- </div>
- <div v-else-if="!challenge" class="admin--table-empty" style="padding:60px;text-align:center;">
- 해당 챌린지를 찾을 수 없습니다.
- <div class="mt--16">
- <button class="admin--btn" @click="goToList">← 목록으로</button>
- </div>
- </div>
- <template v-else>
- <!-- ============================
- 메인 탭
- ============================ -->
- <div class="admin--main-tabs">
- <button
- type="button"
- :class="{ 'is-active': activeMainTab === 'challenge' }"
- @click="activeMainTab = 'challenge'"
- >
- 챌린지관리
- </button>
- <button
- type="button"
- :class="{ 'is-active': activeMainTab === 'applicants' }"
- @click="activeMainTab = 'applicants'"
- >
- 신청자관리
- </button>
- <button
- type="button"
- :class="{ 'is-active': activeMainTab === 'participants' }"
- @click="activeMainTab = 'participants'"
- >
- 참가자관리
- </button>
- </div>
- <!-- ============================
- 신청자관리 (준비중)
- ============================ -->
- <div v-if="activeMainTab === 'applicants'" class="admin--placeholder">
- <p>📝 신청자관리는 준비중입니다.</p>
- </div>
- <!-- ============================
- 참가자관리 (준비중)
- ============================ -->
- <div v-else-if="activeMainTab === 'participants'" class="admin--placeholder">
- <p>👥 참가자관리는 준비중입니다.</p>
- </div>
- <!-- ============================
- 챌린지관리 (기본 활성)
- ============================ -->
- <div v-show="activeMainTab === 'challenge'" class="admin--form">
- <table class="admin--form--table">
- <colgroup>
- <col style="width: 140px;">
- <col>
- </colgroup>
- <tbody>
- <tr>
- <th><div>챌린지명</div></th>
- <td>{{ challenge.name }}</td>
- </tr>
- <tr>
- <th><div>참가비</div></th>
- <td>{{ formatFee(challenge.fee) }}</td>
- </tr>
- <tr>
- <th><div>기간</div></th>
- <td>{{ formatDate(challenge.start_date) }} ~ {{ formatDate(challenge.end_date) }}</td>
- </tr>
- <tr>
- <th><div>최대 참가자</div></th>
- <td>{{ challenge.max_participants }}명</td>
- </tr>
- <tr>
- <th><div>총 라운드</div></th>
- <td>{{ challenge.total_rounds }}R</td>
- </tr>
- <tr>
- <th><div>타이틀 이미지</div></th>
- <td>
- <div v-if="challenge.file_path" class="onboard--photo-grid">
- <div class="onboard--photo-item">
- <img :src="getImageUrl(challenge.file_path)" :alt="challenge.file_name || challenge.name" />
- </div>
- </div>
- <template v-else>-</template>
- </td>
- </tr>
- <tr>
- <th><div>현재 상태</div></th>
- <td>
- <span :class="['admin--badge', statusBadgeClass(challenge.derived_status)]">
- {{ statusLabel(challenge.derived_status) }}
- </span>
- <span v-if="challenge.closed_at" class="txt--muted ml--16">
- ({{ formatDateTime(challenge.closed_at) }} 마감)
- </span>
- </td>
- </tr>
- <tr>
- <th><div>노출 여부</div></th>
- <td>
- <span :class="['admin--badge', challenge.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
- {{ challenge.status_YN === 'Y' ? '사용중' : '미사용' }}
- </span>
- </td>
- </tr>
- <tr>
- <th><div>등록일</div></th>
- <td>{{ formatDateTime(challenge.created_at) }}</td>
- </tr>
- <tr v-if="challenge.description">
- <th><div>상세내용</div></th>
- <td>
- <div class="admin--detail--content" v-html="challenge.description"></div>
- </td>
- </tr>
- </tbody>
- </table>
- <!-- ============================
- 라운드 정보 (탭)
- ============================ -->
- <h3 class="admin--table--middle--title mb--8">라운드 정보</h3>
- <div class="admin--round--tabs">
- <button
- v-for="(round, rIdx) in challenge.rounds"
- :key="round.id"
- type="button"
- class="admin--round--tab"
- :class="{ 'is-active': activeRoundIdx === rIdx }"
- @click="activeRoundIdx = rIdx"
- >
- 라운드 {{ round.round_no }}
- <span v-if="round.closed_at" class="admin--round--tab__badge">종료</span>
- </button>
- </div>
- <div
- v-for="(round, rIdx) in challenge.rounds"
- v-show="activeRoundIdx === rIdx"
- :key="round.id"
- class="admin--round--box--wrap"
- >
- <div class="admin--round--title">
- 라운드 {{ round.round_no }}
- <span>
- {{ round.place_mode === 'all' ? '전체 장소에 동일 적용' : '장소별 개별 설정' }}
- ㆍ 진출자 {{ round.qualified }}{{ rIdx === 0 ? '명' : '%' }}
- </span>
- <span v-if="round.closed_at" class="closed--txt">
- 마감 ({{ formatDateTime(round.closed_at) }})
- </span>
- <button
- v-else-if="currentRoundIdx === rIdx && !challenge.closed_at"
- type="button"
- class="admin--btn-small admin--btn-red"
- @click="confirmCloseRound(round)"
- >
- 라운드 마감
- </button>
- </div>
- <div class="admin--round--box">
- <!-- all 모드 -->
- <div v-if="round.place_mode === 'all'" class="mt--16">
- <p class="mb--8">배정 아이템 ({{ round.items.length }})</p>
- <ul v-if="round.items.length > 0" class="admin--item-modal__grid">
- <li v-for="it in round.items" :key="it.id" class="admin--item-modal__card">
- <div style="padding:12px 12px 14px;">
- <div class="admin--item-modal__thumb">
- <img
- v-if="it.file_path"
- :src="getImageUrl(it.file_path)"
- :alt="it.name"
- />
- <div v-else class="admin--item-modal__no-img">🎁</div>
- </div>
- <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
- <div class="admin--item-modal__meta">
- <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
- <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
- </div>
- </div>
- </li>
- </ul>
- <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
- </div>
- <!-- specific 모드 -->
- <template v-else>
- <!-- <p class="mb--8">장소 묶음 ({{ round.places.length }})</p> -->
- <div
- v-for="(place, pIdx) in round.places"
- :key="place.group_no"
- class="round--place--wrap"
- :class="{ 'mt--16': pIdx > 0 }"
- >
- <div class="admin--round--title">
- 장소 {{ pIdx + 1 }}
- <span class="txt--muted">{{ placeCountText(place.onboards) }}</span>
- </div>
- <div class="place--select--wrap">
- <p class="mt--8 mb--4">장소 목록</p>
- <div class="item--selected--wrap">
- <div
- v-for="o in place.onboards"
- :key="o.id"
- :class="[o.place_type === 'onboard' ? 'item--selected onboard' : 'item--selected']"
- >
- {{ o.place_type === 'onboard' ? '🚤' : '🎣' }} {{ o.place_name || '(삭제됨)' }}
- </div>
- </div>
- <p class="mt--16 mb--4">배정 아이템 ({{ place.items.length }})</p>
- <ul v-if="place.items.length > 0" class="admin--item-modal__grid">
- <li v-for="it in place.items" :key="it.id" class="admin--item-modal__card">
- <div style="padding:12px 12px 14px;">
- <div class="admin--item-modal__thumb">
- <img
- v-if="it.file_path"
- :src="getImageUrl(it.file_path)"
- :alt="it.name"
- />
- <div v-else class="admin--item-modal__no-img">🎁</div>
- </div>
- <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
- <div class="admin--item-modal__meta">
- <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
- <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
- </div>
- </div>
- </li>
- </ul>
- <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
- </div>
- </div>
- </template>
- </div>
- </div>
- <!-- ============================
- 액션 버튼
- ============================ -->
- <div class="admin--form-actions">
- <button type="button" class="admin--btn" @click="goToList">
- ← 목록으로
- </button>
- <button type="button" class="admin--btn admin--btn-blue ml--auto" @click="goToEdit">
- 수정
- </button>
- <button type="button" class="admin--btn admin--btn-red" @click="confirmDelete">
- 삭제
- </button>
- </div>
- <!-- 메시지 -->
- <div v-if="successMessage" class="admin--alert admin--alert-success">{{ successMessage }}</div>
- <div v-if="errorMessage" class="admin--alert admin--alert-error">{{ errorMessage }}</div>
- </div>
- </template>
- <!-- 삭제 확인 모달 -->
- <AdminAlertModal
- v-if="showDeleteModal"
- title="챌린지 삭제"
- :message="`'${challenge?.name}' 챌린지를 삭제하시겠습니까?\n삭제된 챌린지는 복원할 수 있습니다.`"
- type="confirm"
- @confirm="handleDelete"
- @cancel="showDeleteModal = false"
- @close="showDeleteModal = false"
- />
- <!-- 라운드 마감 확인 모달 -->
- <AdminAlertModal
- v-if="showCloseRoundModal"
- title="라운드 마감"
- :message="`라운드 ${closingRound?.round_no}을(를) 마감하시겠습니까?\n마지막 라운드일 경우 챌린지도 함께 자동 종료됩니다.`"
- type="confirm"
- @confirm="handleCloseRound"
- @cancel="() => { showCloseRoundModal = false; closingRound = null }"
- @close="() => { showCloseRoundModal = false; closingRound = null }"
- />
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted } from "vue";
- import { useRoute, useRouter } from "vue-router";
- import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
- definePageMeta({
- layout: "admin",
- middleware: ["auth"],
- });
- const route = useRoute();
- const router = useRouter();
- const { get, post, del } = useApi();
- const { getImageUrl } = useImage();
- const challengeId = Number(route.params.id);
- const isLoading = ref(false);
- const challenge = ref(null);
- const successMessage = ref("");
- const errorMessage = ref("");
- const showDeleteModal = ref(false);
- const activeRoundIdx = ref(0); // 현재 선택된 라운드 탭
- const activeMainTab = ref("challenge"); // 'challenge' | 'applicants' | 'participants'
- const showCloseRoundModal = ref(false);
- const closingRound = ref(null);
- // 현재 라운드 인덱스 (closed_at NULL인 가장 작은 round_no)
- const currentRoundIdx = computed(() => {
- if (!challenge.value?.rounds?.length) return -1;
- const idx = challenge.value.rounds.findIndex((r) => !r.closed_at);
- return idx; // -1 이면 모든 라운드 마감 상태
- });
- const statusLabel = (s) =>
- s === "hidden" ? "비노출"
- : s === "recruiting" ? "모집중"
- : s === "running" ? "진행중"
- : s === "ended" ? "종료"
- : "-";
- const statusBadgeClass = (s) =>
- s === "hidden" ? "admin--badge-hidden"
- : s === "recruiting" ? "admin--badge-recruiting"
- : s === "running" ? "admin--badge-running"
- : s === "ended" ? "admin--badge-ended"
- : "";
- const formatDate = (s) => {
- if (!s) return "-";
- const d = new Date(s.replace(" ", "T"));
- if (isNaN(d.getTime())) return s;
- return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
- };
- const formatDateTime = (s) => {
- if (!s) return "-";
- const d = new Date(s.replace(" ", "T"));
- if (isNaN(d.getTime())) return s;
- return d.toLocaleString("ko-KR", {
- year: "numeric", month: "2-digit", day: "2-digit",
- hour: "2-digit", minute: "2-digit",
- });
- };
- // 장소 묶음의 type별 카운트 텍스트 ("선상 3개ㆍ낚시터 2개" / 한쪽만 있으면 그쪽만)
- const placeCountText = (onboards) => {
- const list = onboards || [];
- const onb = list.filter((o) => o.place_type === "onboard").length;
- const fis = list.filter((o) => o.place_type === "fishing").length;
- const parts = [];
- if (onb > 0) parts.push(`선상 ${onb}개`);
- if (fis > 0) parts.push(`낚시터 ${fis}개`);
- return parts.join("ㆍ") || "장소 없음";
- };
- const formatFee = (fee) => {
- if (fee === null || fee === undefined || fee === "") return "-";
- const num = Number(String(fee).replace(/[^\d]/g, ""));
- if (isNaN(num) || num === 0) return fee + "";
- return num.toLocaleString() + "원";
- };
- const loadChallenge = async () => {
- isLoading.value = true;
- try {
- const { data, error } = await get(`/challenge/${challengeId}`);
- if (error || !data?.success) {
- challenge.value = null;
- return;
- }
- challenge.value = data.data;
- // 페이지 진입 시 활성 탭 = 현재 라운드 (모두 마감이면 마지막 라운드)
- const rounds = challenge.value?.rounds || [];
- if (rounds.length) {
- const idx = rounds.findIndex((r) => !r.closed_at);
- activeRoundIdx.value = idx === -1 ? rounds.length - 1 : idx;
- }
- } catch (e) {
- console.error("[ChallengeDetail] 로드 실패:", e);
- challenge.value = null;
- } finally {
- isLoading.value = false;
- }
- };
- // 라운드 마감 — 확인 모달 → API
- const confirmCloseRound = (round) => {
- closingRound.value = round;
- showCloseRoundModal.value = true;
- };
- const handleCloseRound = async () => {
- showCloseRoundModal.value = false;
- const round = closingRound.value;
- closingRound.value = null;
- if (!round) return;
- errorMessage.value = "";
- try {
- const { data, error } = await post(`/challenge/round/${round.id}/close`, {});
- if (error || !data?.success) {
- errorMessage.value = error?.message || data?.message || "마감에 실패했습니다.";
- return;
- }
- successMessage.value = data.message || "라운드가 마감되었습니다.";
- await loadChallenge();
- } catch (e) {
- console.error("[ChallengeDetail] 마감 실패:", e);
- errorMessage.value = "서버 오류가 발생했습니다.";
- }
- };
- const confirmDelete = () => {
- showDeleteModal.value = true;
- };
- const handleDelete = async () => {
- showDeleteModal.value = false;
- errorMessage.value = "";
- try {
- const { data, error } = await del(`/challenge/${challengeId}`);
- if (error || !data?.success) {
- errorMessage.value = error?.message || data?.message || "삭제에 실패했습니다.";
- return;
- }
- successMessage.value = data.message || "챌린지가 삭제되었습니다.";
- setTimeout(() => router.push("/site-manager/challenge/list"), 800);
- } catch (e) {
- console.error("[ChallengeDetail] 삭제 실패:", e);
- errorMessage.value = "서버 오류가 발생했습니다.";
- }
- };
- const goToList = () => router.push("/site-manager/challenge/list");
- const goToEdit = () => router.push(`/site-manager/challenge/edit/${challengeId}`);
- onMounted(() => {
- loadChallenge();
- });
- </script>
|